@wonseok-han/confluence-sync 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +109 -6
- package/dist/confluence.js +7 -2
- package/dist/convert.js +242 -0
- package/dist/docs.js +72 -11
- package/dist/help.js +30 -4
- package/dist/html2md.js +3 -1
- package/dist/markdown.js +8 -3
- package/dist/obsidian.js +94 -0
- package/dist/pull.js +83 -15
- package/dist/repair.js +145 -0
- package/dist/sync.js +41 -10
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
- **push** — 로컬 `.md` 디렉토리를 Confluence 페이지 트리로 발행. 폴더 구조가 그대로 페이지 계층이 됩니다.
|
|
6
6
|
- **pull** — Confluence 페이지·폴더·**스페이스 전체**를 읽어 `.md` 로 가져옵니다.
|
|
7
7
|
|
|
8
|
-
문서를 git으로 관리하면서(docs-as-code) Confluence를 미러로 두거나, 반대로 Confluence에 쌓인 문서를 마크다운으로 내려받아 쓸 수 있습니다.
|
|
8
|
+
문서를 git으로 관리하면서(docs-as-code) Confluence를 미러로 두거나, 반대로 Confluence에 쌓인 문서를 마크다운으로 내려받아 쓸 수 있습니다.
|
|
9
|
+
|
|
10
|
+
**[Obsidian](#obsidian) vault 와 그대로 맞물립니다** — pull 한 문서는 내부 링크가 이어져 그래프 뷰가 동작하고, vault 에서 쓴 `[[wikilink]]`·`![[embed]]`·frontmatter 를 push 가 그대로 이해합니다.
|
|
9
11
|
|
|
10
12
|
---
|
|
11
13
|
|
|
@@ -20,6 +22,8 @@ confluence-sync --help
|
|
|
20
22
|
|
|
21
23
|
공개 npm 패키지라 **토큰이나 `.npmrc` 설정이 필요 없습니다.** (Node 20+)
|
|
22
24
|
|
|
25
|
+
> 짧은 이름 **`csync`** 도 같이 설치됩니다 — 아래 예시의 `confluence-sync` 는 전부 `csync` 로 바꿔 쓸 수 있습니다.
|
|
26
|
+
|
|
23
27
|
## 빠른 시작
|
|
24
28
|
|
|
25
29
|
```bash
|
|
@@ -122,10 +126,11 @@ docs/ → [CONFLUENCE_PARENT_ID 또는 공간 최상위]
|
|
|
122
126
|
|
|
123
127
|
### 변환 규칙
|
|
124
128
|
|
|
125
|
-
-
|
|
129
|
+
- 페이지 제목은 **frontmatter `title` → 첫 `# 제목` → 파일명** 순으로 정해지고, 본문은 storage format 으로 변환됩니다.
|
|
126
130
|
- 코드블록 → **code 매크로**, 표·리스트·헤딩은 그대로.
|
|
127
|
-
- **내부 `.md`
|
|
128
|
-
- **로컬
|
|
131
|
+
- **내부 `.md` 링크**와 **`[[wikilink]]`** → Confluence 페이지 링크로 자동 변환.
|
|
132
|
+
- **로컬 이미지**와 **`![[embed]]`** → 페이지 첨부로 업로드 후 참조 (같은 파일명은 새 버전으로 갱신). 외부 URL 이미지는 그대로.
|
|
133
|
+
- **YAML frontmatter** 는 메타데이터로만 쓰이고 본문에 새지 않습니다.
|
|
129
134
|
|
|
130
135
|
### 변경 감지와 매핑
|
|
131
136
|
|
|
@@ -162,15 +167,113 @@ confluence-sync pull <pageId|url> # 한 페이지를 현재 폴
|
|
|
162
167
|
confluence-sync pull <url> --out ./docs # 출력 디렉토리 지정
|
|
163
168
|
confluence-sync pull <url> --out ./docs --children # 하위 페이지·폴더까지 트리로 복원
|
|
164
169
|
confluence-sync pull --space --out ./docs # 스페이스 전체 (홈페이지부터 전부)
|
|
170
|
+
confluence-sync pull --space --obsidian --out ~/MyVault # 내부 링크를 [[wikilink]] 로
|
|
165
171
|
```
|
|
166
172
|
|
|
167
173
|
- 대상은 **숫자 ID** 또는 **URL** (`.../pages/<ID>/...` 페이지, `.../folder/<ID>` 폴더) 둘 다 됩니다.
|
|
168
174
|
- **`--children`** — 하위를 재귀 복원합니다. 자식이 있는 페이지는 `<제목>/README.md` 폴더로 펼쳐져 **push 계층 관례와 맞물립니다**.
|
|
169
175
|
- **`--space`** — 대상 없이 스페이스 전체를 가져옵니다. 페이지가 많으면 시간이 걸립니다.
|
|
170
176
|
- **이미지/첨부** 는 문서별로 `attachments/<문서명>/` 아래에 내려받고 링크를 그 경로로 씁니다 (문서 폴더가 이미지로 지저분해지지 않음). 다운로드에는 `read:attachment` 스코프가 필요합니다.
|
|
177
|
+
- **내부 링크** 는 같은 실행에서 함께 받은 페이지끼리 **상대 `.md` 링크로 이어집니다.** 받지 않은 페이지는 절대 URL 로 남습니다(깨진 링크를 만들지 않음).
|
|
178
|
+
- 각 문서 머리에 **frontmatter** 가 붙습니다 — `title` · `pageId` · `spaceKey` · `source` · `updated`. `pageId` 가 다시 push 할 때의 앵커입니다.
|
|
179
|
+
- 제목은 frontmatter 의 `title` 로만 들어가고 **본문에 `# 제목` 을 따로 넣지 않습니다** — 제목을 별도로 표시하는 뷰어(Obsidian 등)에서 두 번 보이기 때문입니다. push 는 `title` 을 먼저 읽으므로 왕복에 문제가 없습니다.
|
|
171
180
|
- 본문은 `export_view`(HTML)를 [turndown](https://github.com/mixmark-io/turndown) 으로 변환합니다 — 코드블록(언어 포함, 미지정은 `plaintext`), GFM 표, 리스트 등.
|
|
172
181
|
|
|
173
|
-
> **무손실 왕복은 아닙니다.** Confluence storage → Markdown 은 근사 변환이라 일부 매크로·레이아웃은 단순화됩니다.
|
|
182
|
+
> **무손실 왕복은 아닙니다.** Confluence storage → Markdown 은 근사 변환이라 일부 매크로·레이아웃은 단순화됩니다.
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## Obsidian
|
|
187
|
+
|
|
188
|
+
pull 이 만드는 마크다운은 **vault 에 그대로 열립니다.** 별도 플러그인이 필요 없습니다.
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
cd ~/MyVault
|
|
192
|
+
confluence-sync pull --space --out . # 스페이스 전체를 vault 로
|
|
193
|
+
confluence-sync pull --space --out . --obsidian # 내부 링크를 [[wikilink]] 로
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
- 내부 링크가 이어져 있어 **그래프 뷰·백링크가 그대로 동작**합니다. 기본값인 상대 `.md` 링크도 Obsidian 에서 정상 동작하며 GitHub·VS Code 에서도 열립니다. vault 안에서만 쓸 거라면 `--obsidian` 으로 `[[wikilink]]` 를 받으세요.
|
|
197
|
+
- frontmatter 는 Obsidian 속성 패널에 표시되고, `source` 로 원본 페이지를 바로 열 수 있습니다.
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## `convert` — 이미 받아둔 문서 손보기
|
|
202
|
+
|
|
203
|
+
**다시 pull 하지 않고** 로컬 `.md` 트리를 제자리에서 고칩니다. Confluence 를 호출하지 않으니 인증도 필요 없습니다.
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
confluence-sync convert --to obsidian ./docs # 상대 .md 링크 → [[wikilink]]
|
|
207
|
+
confluence-sync convert --to markdown ./docs # 되돌리기
|
|
208
|
+
confluence-sync convert --fix ./docs # 옛 pull 결과 보정
|
|
209
|
+
confluence-sync convert --to obsidian --fix ./docs # 둘 다
|
|
210
|
+
confluence-sync convert --fix ./docs --dry-run # 바뀔 파일만 미리보기
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### 대상 지정
|
|
214
|
+
|
|
215
|
+
파일·폴더를 **여러 개** 줄 수 있고, 안 주면 base 전체가 대상입니다.
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
confluence-sync convert --fix ./docs/가이드/설치.md # 파일 하나만
|
|
219
|
+
confluence-sync convert --fix ./docs/a.md ./docs/나머지/ # 파일 + 폴더 혼합
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
`--base` 는 **링크를 해석하는 범위**입니다. 파일 하나만 고칠 때도 주변 문서를 알아야 이름이 유일한지, 링크 대상이 실재하는지 판정할 수 있기 때문입니다. 지정하지 않으면 준 경로들의 공통 상위 폴더가 되며, `--to` 로 링크를 바꿀 때는 **문서 트리 루트를 `--base` 로 알려주는 편이 정확합니다.**
|
|
223
|
+
|
|
224
|
+
```bash
|
|
225
|
+
confluence-sync convert --to obsidian ./docs/가이드/설치.md --base ./docs
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### 다른 곳에 내보내기 — `--out`
|
|
229
|
+
|
|
230
|
+
`--out` 없이 실행하면 **원본을 덮어씁니다.** 원본을 그대로 두고 결과만 따로 받으려면:
|
|
231
|
+
|
|
232
|
+
```bash
|
|
233
|
+
confluence-sync convert --to obsidian --fix ./docs --out ~/MyVault
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
- base 기준 상대 경로를 그대로 재현하고, **본문이 참조하는 첨부(이미지)도 같이 복사**합니다 — 안 그러면 링크가 깨집니다.
|
|
237
|
+
- 안 바뀐 문서도 함께 내보내 **온전한 트리**가 나옵니다.
|
|
238
|
+
- `--out` 이 base 안이면 원본을 오염시키므로 거부합니다.
|
|
239
|
+
|
|
240
|
+
### 링크 표기 (`--to`)
|
|
241
|
+
|
|
242
|
+
두 방향은 서로의 역변환이라 **왕복해도 내용이 보존됩니다.** 안전하게 바꿀 수 있는 링크만 건드립니다.
|
|
243
|
+
|
|
244
|
+
- 외부 URL·이미지·깨진 링크(대상 파일 없음)는 **그대로 둡니다.**
|
|
245
|
+
- 파일명이 트리 안에서 겹치면(`README.md` 여러 개 등) `[[wikilink]]` 로 바꾸지 않고 상대 링크를 유지합니다 — 이름만으로는 어느 노트인지 확정할 수 없기 때문입니다.
|
|
246
|
+
- 코드블록·인라인 코드 안의 링크는 손대지 않습니다.
|
|
247
|
+
|
|
248
|
+
### 보정 (`--fix`)
|
|
249
|
+
|
|
250
|
+
변환기가 개선되기 전에 pull 한 문서에는 옛 결함의 흔적이 남아 있습니다. 스페이스를 통째로 다시 받지 않고 그 흔적만 되돌립니다.
|
|
251
|
+
|
|
252
|
+
| 증상 | 보정 |
|
|
253
|
+
| --- | --- |
|
|
254
|
+
| frontmatter `title` 과 본문 `# 제목` 이 중복 | 겹치는 H1 제거 (Obsidian 에서 제목이 두 번 보임) |
|
|
255
|
+
| 모든 코드블록이 ` ```java ` | 실제 Java 가 아니면 `plaintext` 로 (Confluence 가 언어 미지정 시 `brush: java` 를 붙이는 탓) |
|
|
256
|
+
| 제목에 `[data-colorid=…]{color:#…}` | 본문에 새어 나온 CSS 제거 |
|
|
257
|
+
| `:11\_eleven\_blue:` · `\[선택 가능\]` · `**7️⃣**\-**1️⃣**` | 불필요한 `\` 이스케이프 해제 |
|
|
258
|
+
| 리스트 항목마다 빈 줄 | 같은 종류 항목끼리 붙임 (번호↔글머리 경계는 유지) |
|
|
259
|
+
| 코드블록 안 줄 끝 공백 | 정리 |
|
|
260
|
+
|
|
261
|
+
**멱등적입니다** — 두 번 돌려도 더 바뀌지 않고, 최신 pull 결과에는 아무것도 하지 않습니다. 애매하면 손대지 않는 쪽을 택하므로 진짜 Java 코드블록은 그대로 남습니다. 파일을 덮어쓰니 `--dry-run` 으로 먼저 확인하세요.
|
|
262
|
+
|
|
263
|
+
반대로 **vault 를 그대로 push** 할 수 있습니다. Obsidian 문법을 그대로 이해합니다.
|
|
264
|
+
|
|
265
|
+
| vault 에서 쓴 것 | Confluence 에 올라가는 것 |
|
|
266
|
+
| --- | --- |
|
|
267
|
+
| `[[설계]]` · `[[구조/설계\|설계 문서]]` | 페이지 링크 (짧은 이름·경로 모두 인식) |
|
|
268
|
+
| `![[diagram.png]]` | 첨부 이미지 |
|
|
269
|
+
| frontmatter `title:` | 페이지 제목 |
|
|
270
|
+
| frontmatter `pageId:` | **그 페이지를 갱신** (새로 만들지 않음) |
|
|
271
|
+
|
|
272
|
+
- 코드블록·인라인 코드 안의 `[[...]]` 는 건드리지 않고, **대상을 못 찾은 링크는 원문 그대로** 둡니다(조용히 사라지지 않습니다).
|
|
273
|
+
- `.obsidian/` `.trash/` 등 숨김 폴더는 동기화 대상에서 제외됩니다.
|
|
274
|
+
- `pageId` 덕분에 매핑 파일(`.confluence-sync.json`)을 잃어버려도 **중복 페이지가 생기지 않습니다** — 원본을 찾아가 갱신합니다(`[연결]` 로 표시).
|
|
275
|
+
|
|
276
|
+
> pull → 편집 → push 왕복이 목적이라면 frontmatter 를 지우지 마세요. `pageId` 가 원본 페이지를 가리키는 유일한 단서입니다.
|
|
174
277
|
|
|
175
278
|
---
|
|
176
279
|
|
|
@@ -202,7 +305,7 @@ git push && git push --tags
|
|
|
202
305
|
|
|
203
306
|
워크플로는 `package.json` 버전과 태그가 일치하는지 검증한 뒤 배포합니다. 변경 이력은 [CHANGELOG.md](CHANGELOG.md) 를 참고하세요.
|
|
204
307
|
|
|
205
|
-
>
|
|
308
|
+
> npm 발행은 **Trusted Publishing(OIDC)** 을 씁니다 — 장기 토큰 없이 GitHub Actions 가 단기 자격증명으로 배포합니다.
|
|
206
309
|
|
|
207
310
|
## 라이선스
|
|
208
311
|
|
package/dist/confluence.js
CHANGED
|
@@ -158,17 +158,22 @@ export function createClient(cfg, opts) {
|
|
|
158
158
|
return res.json();
|
|
159
159
|
}
|
|
160
160
|
/**
|
|
161
|
-
* 콘텐츠 노드 조회. type + 제목 + export_view(HTML) + storage(원본 XHTML)
|
|
161
|
+
* 콘텐츠 노드 조회. type + 제목 + export_view(HTML) + storage(원본 XHTML) + 출처 메타.
|
|
162
162
|
* storage 는 코드블록의 "진짜" 언어를 얻는 데 쓴다(export_view 는 언어 미지정 시 brush:java 기본값이 붙어 신뢰 불가).
|
|
163
|
+
* url/updated 는 frontmatter 의 출처 정보로 쓰이며, 응답에 없으면 undefined.
|
|
163
164
|
*/
|
|
164
165
|
async function getNode(id) {
|
|
165
|
-
const data = await getV1(`/rest/api/content/${id}?expand=body.export_view,body.storage`);
|
|
166
|
+
const data = await getV1(`/rest/api/content/${id}?expand=body.export_view,body.storage,version`);
|
|
167
|
+
const base = data._links?.base;
|
|
168
|
+
const webui = data._links?.webui;
|
|
166
169
|
return {
|
|
167
170
|
id: data.id,
|
|
168
171
|
type: data.type ?? 'page',
|
|
169
172
|
title: data.title ?? id,
|
|
170
173
|
html: data.body?.export_view?.value ?? '',
|
|
171
174
|
storage: data.body?.storage?.value ?? '',
|
|
175
|
+
url: base && webui ? `${base}${webui}` : undefined,
|
|
176
|
+
updated: data.version?.when,
|
|
172
177
|
};
|
|
173
178
|
}
|
|
174
179
|
/** 자식 노드 목록(kind = page|folder, 전체 페이지네이션). */
|
package/dist/convert.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `confluence-sync convert [--to obsidian|markdown] [--fix] [파일|폴더...] [--base <dir>] [--dry-run]`
|
|
3
|
+
* 이미 내려받은 .md 를 제자리에서 손본다. Confluence 호출도 인증도 없다.
|
|
4
|
+
*
|
|
5
|
+
* --to obsidian — 상대 .md 링크 → [[wikilink]]
|
|
6
|
+
* --to markdown — [[wikilink]] · ![[embed]] → 상대 .md 링크 (되돌리기)
|
|
7
|
+
* --fix — 옛 변환기가 남긴 흔적 보정(코드블록 언어·CSS 잔해·이스케이프·빈 줄)
|
|
8
|
+
*
|
|
9
|
+
* 스페이스를 통째로 다시 받지 않고도 최신 변환 품질을 얻기 위한 것이다.
|
|
10
|
+
* 링크는 대상이 트리 안에 실제로 있을 때만 바꾼다(외부 URL·이미지·깨진 링크는 손대지 않는다).
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync, copyFileSync } from 'node:fs';
|
|
13
|
+
import { resolve, relative, dirname, basename, join } from 'node:path';
|
|
14
|
+
import { collectMarkdown, collectAssets, buildVault, vaultResolver } from './docs.js';
|
|
15
|
+
import { linksToWikilinks, resolveWikilinks } from './obsidian.js';
|
|
16
|
+
import { repairMarkdown, totalFixes } from './repair.js';
|
|
17
|
+
import { bold, cyan, dim, gray, green, red, yellow } from './colors.js';
|
|
18
|
+
function optVal(argv, name) {
|
|
19
|
+
const i = argv.indexOf(name);
|
|
20
|
+
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined;
|
|
21
|
+
}
|
|
22
|
+
const decode = (s) => { try {
|
|
23
|
+
return decodeURIComponent(s);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return s;
|
|
27
|
+
} };
|
|
28
|
+
const posix = (s) => s.split('\\').join('/');
|
|
29
|
+
//  — 본문이 참조하는 로컬 이미지(--out 으로 복사할 때 같이 옮긴다)
|
|
30
|
+
const IMAGE = /!\[[^\]]*\]\((<[^>]+>|[^()\s]+)\)/g;
|
|
31
|
+
/** md 본문이 참조하는 로컬 첨부의 절대경로들(외부 URL 제외, 실제 존재하는 것만). */
|
|
32
|
+
function referencedAssets(text, fileAbs) {
|
|
33
|
+
const out = [];
|
|
34
|
+
for (const m of text.matchAll(IMAGE)) {
|
|
35
|
+
const dest = decode(m[1].replace(/^<(.*)>$/, '$1')).split('#')[0];
|
|
36
|
+
if (/^(https?:|data:)/i.test(dest))
|
|
37
|
+
continue;
|
|
38
|
+
const abs = resolve(dirname(fileAbs), dest);
|
|
39
|
+
if (existsSync(abs))
|
|
40
|
+
out.push(abs);
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
/** 준 경로들을 모두 담는 가장 가까운 폴더(파일은 그 파일이 있는 폴더로 친다). 인자가 없으면 null. */
|
|
45
|
+
function commonAncestor(paths) {
|
|
46
|
+
if (!paths.length)
|
|
47
|
+
return null;
|
|
48
|
+
const dirs = paths.map((p) => {
|
|
49
|
+
const abs = resolve(p);
|
|
50
|
+
return statSync(abs).isDirectory() ? abs : dirname(abs);
|
|
51
|
+
});
|
|
52
|
+
const split = dirs.map((d) => d.split('/'));
|
|
53
|
+
const head = split[0];
|
|
54
|
+
let i = 0;
|
|
55
|
+
while (i < head.length && split.every((s) => s[i] === head[i]))
|
|
56
|
+
i++;
|
|
57
|
+
return split.length === 1 ? dirs[0] : head.slice(0, i).join('/') || '/';
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* 상대 .md 링크 → [[wikilink]].
|
|
61
|
+
* wikilink 는 vault 어디서든 "이름"으로 찾으므로, 파일명이 트리 안에서 유일할 때만 바꾼다
|
|
62
|
+
* (겹치면 엉뚱한 노트로 이어질 수 있어 상대 링크를 그대로 둔다).
|
|
63
|
+
*/
|
|
64
|
+
function toObsidian(text, fileAbs, baseDir, nameCount) {
|
|
65
|
+
return linksToWikilinks(text, (dest, label) => {
|
|
66
|
+
if (/^(https?:|mailto:|#)/i.test(dest))
|
|
67
|
+
return null; // 외부 링크·앵커
|
|
68
|
+
const [pathPart, ...hash] = decode(dest).split('#');
|
|
69
|
+
if (!/\.md$/i.test(pathPart))
|
|
70
|
+
return null;
|
|
71
|
+
const targetAbs = resolve(dirname(fileAbs), pathPart);
|
|
72
|
+
if (!existsSync(targetAbs))
|
|
73
|
+
return null; // 깨진 링크는 손대지 않는다
|
|
74
|
+
if (relative(baseDir, targetAbs).startsWith('..'))
|
|
75
|
+
return null; // 트리 밖
|
|
76
|
+
const name = basename(targetAbs).replace(/\.md$/i, '');
|
|
77
|
+
if (nameCount.get(name) !== 1)
|
|
78
|
+
return null; // 이름이 겹치면 상대 링크 유지
|
|
79
|
+
const anchor = hash.length ? `#${hash.join('#')}` : '';
|
|
80
|
+
const l = label.trim();
|
|
81
|
+
return l && l !== name ? `[[${name}${anchor}|${l}]]` : `[[${name}${anchor}]]`;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
export async function runConvert(argv) {
|
|
85
|
+
const toRaw = optVal(argv, '--to');
|
|
86
|
+
const fix = argv.includes('--fix');
|
|
87
|
+
if (toRaw !== undefined && toRaw !== 'obsidian' && toRaw !== 'markdown') {
|
|
88
|
+
console.error(red(`✗ 알 수 없는 변환 방향: ${toRaw}`) + '\n --to obsidian | --to markdown');
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
const to = toRaw;
|
|
92
|
+
if (!to && !fix) {
|
|
93
|
+
console.error(red('✗ 할 일을 지정하세요: --to <형식> 또는 --fix') +
|
|
94
|
+
`\n 예) ${cyan('confluence-sync convert --to obsidian ./docs')}` +
|
|
95
|
+
`\n ${cyan('confluence-sync convert --fix ./docs')} ${dim('(옛 pull 결과 보정)')}` +
|
|
96
|
+
`\n ${cyan('confluence-sync convert --to obsidian --fix ./docs')} ${dim('(둘 다)')}`);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
const dryRun = argv.includes('--dry-run');
|
|
100
|
+
// --out: 원본을 두고 변환 결과를 다른 트리에 쓴다(base 기준 상대 경로를 그대로 재현)
|
|
101
|
+
const outOpt = optVal(argv, '--out');
|
|
102
|
+
const outDir = outOpt ? resolve(outOpt) : undefined;
|
|
103
|
+
// 위치 인자: ['convert', <파일|폴더>...] (옵션 값은 건너뛴다)
|
|
104
|
+
const positionals = [];
|
|
105
|
+
for (let i = 0; i < argv.length; i++) {
|
|
106
|
+
if (argv[i] === '--to' || argv[i] === '--base' || argv[i] === '--out') {
|
|
107
|
+
i++;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (argv[i].startsWith('-'))
|
|
111
|
+
continue;
|
|
112
|
+
positionals.push(argv[i]);
|
|
113
|
+
}
|
|
114
|
+
const targets = positionals.slice(1); // [0] 은 'convert'
|
|
115
|
+
for (const t of targets) {
|
|
116
|
+
if (!existsSync(resolve(t))) {
|
|
117
|
+
console.error(red(`✗ 없는 경로: ${t}`));
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* baseDir 은 **링크를 해석하는 범위**다. 파일 하나만 고칠 때도 주변 문서를 알아야
|
|
123
|
+
* (이름이 유일한지·링크 대상이 실재하는지) 판정할 수 있다.
|
|
124
|
+
* 기본값은 준 경로들의 공통 상위 폴더 — 폴더를 주면 그 폴더, 파일을 주면 그 파일이 있는 폴더다.
|
|
125
|
+
* 링크 변환(--to)에서 트리 전체를 봐야 한다면 --base 로 루트를 지정한다.
|
|
126
|
+
*/
|
|
127
|
+
const baseOpt = optVal(argv, '--base');
|
|
128
|
+
const baseDir = resolve(baseOpt ?? commonAncestor(targets) ?? process.cwd());
|
|
129
|
+
if (!existsSync(baseDir) || !statSync(baseDir).isDirectory()) {
|
|
130
|
+
console.error(red(`✗ --base 가 디렉토리가 아닙니다: ${baseDir}`));
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
const allMd = collectMarkdown(baseDir); // 링크 해석용(전체)
|
|
134
|
+
const mdRels = allMd.map((f) => posix(relative(baseDir, f)));
|
|
135
|
+
const vault = buildVault(mdRels, collectAssets(baseDir).map((f) => posix(relative(baseDir, f))));
|
|
136
|
+
// obsidian 방향의 이름 중복 검사도 트리 전체 기준이어야 한다
|
|
137
|
+
const nameCount = new Map();
|
|
138
|
+
for (const f of allMd) {
|
|
139
|
+
const n = basename(f).replace(/\.md$/i, '');
|
|
140
|
+
nameCount.set(n, (nameCount.get(n) ?? 0) + 1);
|
|
141
|
+
}
|
|
142
|
+
// 실제로 고쳐 쓸 파일(선택). 경로를 안 주면 base 전체.
|
|
143
|
+
const selected = targets.length
|
|
144
|
+
? allMd.filter((f) => targets.some((t) => {
|
|
145
|
+
const abs = resolve(t);
|
|
146
|
+
return f === abs || f.startsWith(abs + '/');
|
|
147
|
+
}))
|
|
148
|
+
: allMd;
|
|
149
|
+
// base 밖의 .md 를 직접 지목한 경우(예: 이웃 폴더 파일) 는 위 필터에 안 걸린다
|
|
150
|
+
const outside = targets
|
|
151
|
+
.map((t) => resolve(t))
|
|
152
|
+
.filter((abs) => statSync(abs).isFile() && !allMd.includes(abs));
|
|
153
|
+
if (outside.length) {
|
|
154
|
+
console.error(yellow(`⚠ --base(${baseDir}) 밖의 파일은 건너뜁니다:`) + '\n ' + outside.join('\n '));
|
|
155
|
+
}
|
|
156
|
+
if (!selected.length) {
|
|
157
|
+
console.error(red('✗ 대상 .md 가 없습니다.') + dim('\n (파일을 직접 지목했다면 --base 로 문서 트리 루트를 알려주세요)'));
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
const mdAbs = selected;
|
|
161
|
+
const jobs = [
|
|
162
|
+
to === 'obsidian' ? '상대 .md 링크 → [[wikilink]]' : to === 'markdown' ? '[[wikilink]] → 상대 .md 링크' : '',
|
|
163
|
+
fix ? '변환 흔적 보정' : '',
|
|
164
|
+
].filter(Boolean).join(' + ');
|
|
165
|
+
const scope = mdAbs.length === allMd.length
|
|
166
|
+
? `${mdAbs.length}건`
|
|
167
|
+
: `선택 ${mdAbs.length}/${allMd.length}건`;
|
|
168
|
+
if (outDir && (outDir === baseDir || outDir.startsWith(baseDir + '/'))) {
|
|
169
|
+
console.error(red(`✗ --out 이 base 안에 있습니다: ${outDir}`) + dim('\n 원본 트리를 덮어쓰지 않도록 base 밖의 경로를 쓰세요.'));
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
console.log(`${dim('base:')} ${cyan(baseDir)}\n` +
|
|
173
|
+
`${dim('대상:')} ${scope} ${dim('—')} ${jobs}` +
|
|
174
|
+
(outDir ? `\n${dim('출력:')} ${cyan(outDir)} ${dim('(원본 유지)')}` : '') +
|
|
175
|
+
(dryRun ? dim(' (dry-run)') : ''));
|
|
176
|
+
let changed = 0;
|
|
177
|
+
const copiedAssets = new Set();
|
|
178
|
+
const total = { cssJunk: 0, codeLang: 0, trailingWs: 0, escapes: 0, tightList: 0, dupTitle: 0 };
|
|
179
|
+
for (const abs of mdAbs) {
|
|
180
|
+
const rel = posix(relative(baseDir, abs));
|
|
181
|
+
const before = readFileSync(abs, 'utf8');
|
|
182
|
+
let text = before;
|
|
183
|
+
const notes = [];
|
|
184
|
+
// 보정을 먼저 한다 — 이스케이프가 풀려야(\[…\] → […]) 링크 인식이 정확해진다
|
|
185
|
+
if (fix) {
|
|
186
|
+
const r = repairMarkdown(text);
|
|
187
|
+
text = r.text;
|
|
188
|
+
const n = totalFixes(r.stats);
|
|
189
|
+
if (n) {
|
|
190
|
+
for (const k of Object.keys(total))
|
|
191
|
+
total[k] += r.stats[k];
|
|
192
|
+
notes.push(dim(`보정 ${n}`));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (to === 'obsidian')
|
|
196
|
+
text = toObsidian(text, abs, baseDir, nameCount);
|
|
197
|
+
else if (to === 'markdown')
|
|
198
|
+
text = resolveWikilinks(text, vaultResolver(rel, vault));
|
|
199
|
+
// 제자리 변환이면 바뀐 것만 쓰지만, --out 이면 대상 전부를 내보낸다(온전한 트리가 나와야 하므로)
|
|
200
|
+
if (!outDir && text === before)
|
|
201
|
+
continue;
|
|
202
|
+
if (outDir) {
|
|
203
|
+
const target = join(outDir, rel);
|
|
204
|
+
if (!dryRun) {
|
|
205
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
206
|
+
writeFileSync(target, text);
|
|
207
|
+
// 이미지가 따라가지 않으면 링크가 깨진다 — 참조된 첨부를 같은 상대 위치로 복사
|
|
208
|
+
for (const asset of referencedAssets(text, abs)) {
|
|
209
|
+
const arel = posix(relative(baseDir, asset));
|
|
210
|
+
if (arel.startsWith('..') || copiedAssets.has(arel))
|
|
211
|
+
continue; // base 밖 첨부는 두고 온다
|
|
212
|
+
const dstAsset = join(outDir, arel);
|
|
213
|
+
mkdirSync(dirname(dstAsset), { recursive: true });
|
|
214
|
+
copyFileSync(asset, dstAsset);
|
|
215
|
+
copiedAssets.add(arel);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
else if (!dryRun) {
|
|
220
|
+
writeFileSync(abs, text);
|
|
221
|
+
}
|
|
222
|
+
// --out 이면 안 바뀐 파일도 그대로 내보내지만, 로그는 실제로 손댄 것만 남긴다
|
|
223
|
+
if (text !== before) {
|
|
224
|
+
changed++;
|
|
225
|
+
console.log(` ${cyan('↻')} ${rel}${notes.length ? ' ' + notes.join(' ') : ''}`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
console.log('\n' + bold('완료') + ` ${changed ? green(`${changed}개 파일 변경`) : gray('고칠 것 없음')}` +
|
|
229
|
+
(outDir ? ` ${cyan(`${mdAbs.length}개 파일 출력`)}${copiedAssets.size ? dim(`, 첨부 ${copiedAssets.size}`) : ''}` : '') +
|
|
230
|
+
` ${dim(`대상 ${mdAbs.length}`)}`);
|
|
231
|
+
if (fix && totalFixes(total)) {
|
|
232
|
+
console.log(dim(' 보정:') +
|
|
233
|
+
` 중복 제목 ${total.dupTitle}` +
|
|
234
|
+
` 코드블록 언어 ${total.codeLang}` +
|
|
235
|
+
` CSS 잔해 ${total.cssJunk}` +
|
|
236
|
+
` 이스케이프 ${total.escapes}` +
|
|
237
|
+
` 리스트 빈 줄 ${total.tightList}` +
|
|
238
|
+
` 줄끝 공백 ${total.trailingWs}`);
|
|
239
|
+
}
|
|
240
|
+
if (dryRun)
|
|
241
|
+
console.log(dim('--dry-run: 파일을 쓰지 않았습니다.'));
|
|
242
|
+
}
|
package/dist/docs.js
CHANGED
|
@@ -3,29 +3,90 @@
|
|
|
3
3
|
* 폴더의 README.md 가 대표 페이지가 되고 같은 폴더의 다른 문서는 그 자식이 된다.
|
|
4
4
|
*/
|
|
5
5
|
import { readdirSync, readFileSync } from 'node:fs';
|
|
6
|
-
import { join, resolve, relative } from 'node:path';
|
|
6
|
+
import { join, resolve, relative, basename, dirname } from 'node:path';
|
|
7
7
|
import { splitTitleAndBody } from './markdown.js';
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
import { splitFrontmatter } from './obsidian.js';
|
|
9
|
+
/**
|
|
10
|
+
* 숨김 디렉토리는 문서가 아니다. Obsidian vault 의 `.obsidian/`(설정)·`.trash/`(휴지통) 과
|
|
11
|
+
* `.git/`·`node_modules/` 가 여기서 걸러진다.
|
|
12
|
+
*/
|
|
13
|
+
const skipDir = (name) => name.startsWith('.') || name === 'node_modules';
|
|
14
|
+
/** dir 이하의 파일 절대경로를 재귀 수집(pick 이 true 인 것만). */
|
|
15
|
+
function walk(dir, pick) {
|
|
10
16
|
const out = [];
|
|
11
17
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
12
18
|
const full = join(dir, entry.name);
|
|
13
|
-
if (entry.isDirectory())
|
|
14
|
-
|
|
15
|
-
|
|
19
|
+
if (entry.isDirectory()) {
|
|
20
|
+
if (!skipDir(entry.name))
|
|
21
|
+
out.push(...walk(full, pick));
|
|
22
|
+
}
|
|
23
|
+
else if (entry.isFile() && pick(entry.name)) {
|
|
16
24
|
out.push(full);
|
|
25
|
+
}
|
|
17
26
|
}
|
|
18
27
|
return out;
|
|
19
28
|
}
|
|
20
|
-
|
|
29
|
+
const isMd = (name) => name.toLowerCase().endsWith('.md');
|
|
30
|
+
/** dir 이하의 모든 .md 절대경로를 재귀 수집 */
|
|
31
|
+
export function collectMarkdown(dir) {
|
|
32
|
+
return walk(dir, isMd);
|
|
33
|
+
}
|
|
34
|
+
/** dir 이하의 .md 가 아닌 파일(이미지 등 첨부 후보) 절대경로를 재귀 수집 */
|
|
35
|
+
export function collectAssets(dir) {
|
|
36
|
+
return walk(dir, (n) => !isMd(n));
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* 문서 1건을 읽어 제목·본문·frontmatter 로 분해한다.
|
|
40
|
+
* 제목 우선순위: frontmatter `title` > 첫 H1 > 파일명. H1 은 어느 경우든 본문에서 제거된다
|
|
41
|
+
* (Confluence 는 페이지 제목을 따로 가지므로 본문에 남으면 제목이 두 번 보인다).
|
|
42
|
+
*/
|
|
43
|
+
export function readDoc(baseDir, rel) {
|
|
44
|
+
const { data, body: afterFm } = splitFrontmatter(readFileSync(resolve(baseDir, rel), 'utf8'));
|
|
45
|
+
const { title, body } = splitTitleAndBody(afterFm, basename(rel).replace(/\.md$/i, ''));
|
|
46
|
+
return { title: (data.title || title).trim(), body, fm: data };
|
|
47
|
+
}
|
|
48
|
+
/** base 상대 경로 → 문서 제목 인덱스 */
|
|
21
49
|
export function buildTitleIndex(rels, baseDir) {
|
|
22
50
|
const idx = {};
|
|
23
|
-
for (const rel of rels)
|
|
24
|
-
|
|
25
|
-
idx[rel] = title;
|
|
26
|
-
}
|
|
51
|
+
for (const rel of rels)
|
|
52
|
+
idx[rel] = readDoc(baseDir, rel).title;
|
|
27
53
|
return idx;
|
|
28
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* wikilink 대상 이름 → base 상대 경로 인덱스.
|
|
57
|
+
* Obsidian 은 `[[파일명]]`(짧은 이름)과 `[[폴더/파일명]]`(경로) 을 모두 허용하므로 둘 다 등록한다.
|
|
58
|
+
* 짧은 이름이 겹치면 경로가 사전순으로 앞서는 쪽이 이긴다(Obsidian 의 "가장 가까운 것" 규칙과는 다르지만 결정적이다).
|
|
59
|
+
*/
|
|
60
|
+
export function buildVault(mdRels, assetRels) {
|
|
61
|
+
const index = (rels, stripExt) => {
|
|
62
|
+
const idx = {};
|
|
63
|
+
for (const rel of [...rels].sort()) {
|
|
64
|
+
const withoutExt = stripExt ? rel.replace(/\.md$/i, '') : rel;
|
|
65
|
+
for (const key of [withoutExt, basename(withoutExt)]) {
|
|
66
|
+
const k = key.toLowerCase();
|
|
67
|
+
if (!(k in idx))
|
|
68
|
+
idx[k] = rel; // 먼저 등록된(사전순 앞선) 것이 이긴다
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return idx;
|
|
72
|
+
};
|
|
73
|
+
return { notes: index(mdRels, true), assets: index(assetRels, false) };
|
|
74
|
+
}
|
|
75
|
+
/** 파일(rel) 기준으로 wikilink 대상을 상대경로로 풀어주는 resolver. */
|
|
76
|
+
export function vaultResolver(rel, vault) {
|
|
77
|
+
const dir = dirname(rel) === '.' ? '' : dirname(rel);
|
|
78
|
+
return (target, embed) => {
|
|
79
|
+
const key = target.toLowerCase().replace(/\.md$/i, '');
|
|
80
|
+
// 임베드(![[...]])는 대개 이미지, 링크([[...]])는 대개 노트 → 각자 먼저 찾아본다
|
|
81
|
+
const hit = embed
|
|
82
|
+
? (vault.assets[target.toLowerCase()] ?? vault.notes[key])
|
|
83
|
+
: (vault.notes[key] ?? vault.assets[target.toLowerCase()]);
|
|
84
|
+
if (!hit)
|
|
85
|
+
return null;
|
|
86
|
+
const r = relative(dir || '.', hit).split('\\').join('/');
|
|
87
|
+
return r || null;
|
|
88
|
+
};
|
|
89
|
+
}
|
|
29
90
|
const isReadme = (base) => /README\.md$/.test(base);
|
|
30
91
|
/** 폴더 경로 → 그 폴더의 대표 README(base 상대) 인덱스 */
|
|
31
92
|
export function buildFolderIndex(rels) {
|
package/dist/help.js
CHANGED
|
@@ -16,12 +16,14 @@ export function printHelp() {
|
|
|
16
16
|
const h = (s) => bold(s); // 섹션 제목
|
|
17
17
|
const o = (s) => cyan(s); // 옵션/명령
|
|
18
18
|
console.log(`${bold(cyan(`confluence-sync v${readPkgVersion()}`))}
|
|
19
|
-
${dim('
|
|
19
|
+
${dim('Confluence Cloud ↔ Markdown 양방향 동기화 CLI.')}
|
|
20
|
+
${dim('짧은 이름')} ${o('csync')} ${dim('로도 실행할 수 있습니다(아래 confluence-sync 를 csync 로 대체 가능).')}
|
|
20
21
|
|
|
21
22
|
${h('사용법:')}
|
|
22
23
|
${o('confluence-sync')} [옵션] [경로...] 문서를 동기화(md → Confluence)
|
|
23
24
|
${o('confluence-sync init')} 대화형으로 .env 설정 파일 생성
|
|
24
25
|
${o('confluence-sync pull <pageId|url>')} Confluence 페이지/폴더를 .md 로 가져오기(역방향)
|
|
26
|
+
${o('confluence-sync convert --to <형식>')} 이미 받은 .md 의 링크 표기를 바꾸기(로컬 전용)
|
|
25
27
|
${o('confluence-sync --help | --version')}
|
|
26
28
|
|
|
27
29
|
${h('동기화 옵션:')}
|
|
@@ -42,13 +44,37 @@ ${h('경로 인자:')}
|
|
|
42
44
|
예) ${o('confluence-sync 20-design')} ${o('confluence-sync 90-glossary.md')}
|
|
43
45
|
|
|
44
46
|
${h('pull(역방향) 옵션:')}
|
|
45
|
-
${o('--out <dir>')}
|
|
46
|
-
${o('--children')}
|
|
47
|
-
${o('--space')}
|
|
47
|
+
${o('--out <dir>')} .md 를 생성할 디렉토리(기본: 현재 폴더)
|
|
48
|
+
${o('--children')} 하위 페이지까지 재귀적으로 가져와 폴더 트리로 복원
|
|
49
|
+
${o('--space')} 대상 없이 스페이스(CONFLUENCE_SPACE_KEY) 홈페이지부터 전체를 가져옴
|
|
50
|
+
${o('--obsidian')} 내부 링크를 [[wikilink]] 로 출력(기본: 어디서나 열리는 상대 .md 링크)
|
|
48
51
|
(이미지는 항상 attachments/<문서명>/ 하위에 저장됨)
|
|
49
52
|
예) ${o('confluence-sync pull https://.../pages/12345/Title --out ./docs --children')}
|
|
50
53
|
${o('confluence-sync pull --space --out ./docs')}
|
|
51
54
|
|
|
55
|
+
${h('convert(이미 받아둔 .md 손보기) 옵션:')} ${dim('Confluence 호출·인증 없음')}
|
|
56
|
+
${o('--to obsidian')} 상대 .md 링크 → [[wikilink]]
|
|
57
|
+
${o('--to markdown')} [[wikilink]] · ![[embed]] → 상대 .md 링크 (되돌리기)
|
|
58
|
+
${o('--fix')} 옛 pull 결과 보정 — 코드블록 언어(java→plaintext)·CSS 잔해·
|
|
59
|
+
불필요한 \\ 이스케이프·리스트 사이 빈 줄
|
|
60
|
+
${o('--base <dir>')} 링크를 해석할 문서 트리 루트(기본: 준 경로들의 공통 상위 폴더)
|
|
61
|
+
${o('--out <dir>')} 원본을 두고 결과를 다른 트리에 쓰기(참조된 첨부도 함께 복사)
|
|
62
|
+
${o('--dry-run')} 바뀔 파일만 출력하고 쓰지 않음
|
|
63
|
+
|
|
64
|
+
파일·폴더를 여러 개 줄 수 있고, 안 주면 base 전체가 대상입니다.
|
|
65
|
+
${o('--out')} 없이 실행하면 ${dim('원본 파일을 덮어씁니다')}.
|
|
66
|
+
예) ${o('confluence-sync convert --fix ./docs --dry-run')}
|
|
67
|
+
${o('confluence-sync convert --to obsidian --fix ./docs --out ~/MyVault')}
|
|
68
|
+
${o('confluence-sync convert --fix ./docs/가이드/설치.md --base ./docs')} ${dim('(파일 하나만)')}
|
|
69
|
+
|
|
70
|
+
${h('Obsidian:')}
|
|
71
|
+
pull 결과는 vault 에서 바로 열립니다. 함께 받은 페이지끼리의 내부 링크가 이어져 그래프·백링크가
|
|
72
|
+
동작하고, 각 문서의 frontmatter 에 pageId 가 남아 원본 페이지를 가리킵니다.
|
|
73
|
+
[[wikilink]] 표기를 원하면 ${o('pull --obsidian')}, 이미 받아둔 문서라면 ${o('convert --to obsidian')}.
|
|
74
|
+
|
|
75
|
+
push 는 vault 문법을 그대로 이해합니다: ${o('[[wikilink]]')} · ${o('![[embed]]')} · YAML frontmatter
|
|
76
|
+
(frontmatter 의 ${o('title')} 이 페이지 제목이 되고, ${o('pageId')} 가 있으면 매핑이 없어도 그 페이지를 갱신합니다).
|
|
77
|
+
|
|
52
78
|
${h('설정(.env):')}
|
|
53
79
|
실행 위치(cwd)의 .env 또는 셸 환경변수를 읽습니다.
|
|
54
80
|
필수: CONFLUENCE_BASE_URL, CONFLUENCE_EMAIL, CONFLUENCE_API_TOKEN, CONFLUENCE_SPACE_KEY
|
package/dist/html2md.js
CHANGED
|
@@ -22,7 +22,7 @@ const linkDest = (p) => (/\s/.test(p) ? `<${p}>` : p);
|
|
|
22
22
|
* (예: `**7️⃣**\-**1️⃣**` — 두 굵은 글씨 사이 텍스트 노드가 "-" 하나라 노드 시작으로 취급)
|
|
23
23
|
* 줄 맨 앞의 진짜 마커 이스케이프는 보존하고, 그 뒤(줄 중간)의 것만 되돌린다. 코드블록은 건드리지 않는다.
|
|
24
24
|
*/
|
|
25
|
-
function relaxEscapes(md) {
|
|
25
|
+
export function relaxEscapes(md) {
|
|
26
26
|
let inFence = false;
|
|
27
27
|
return md
|
|
28
28
|
.split('\n')
|
|
@@ -39,6 +39,8 @@ function relaxEscapes(md) {
|
|
|
39
39
|
.slice(head.length)
|
|
40
40
|
// 줄 중간의 - + . > 는 마커가 아니므로 이스케이프 불필요
|
|
41
41
|
.replace(/\\([-+.>])/g, '$1')
|
|
42
|
+
// 단어 내부 밑줄은 CommonMark 에서 강조가 아니다 (:11_eleven_square_blue: 같은 이모지 코드)
|
|
43
|
+
.replace(/(\w)\\_(?=\w)/g, '$1_')
|
|
42
44
|
// 링크가 아닌 대괄호 쌍(뒤에 ( 나 [ 가 오지 않음)은 이스케이프 불필요
|
|
43
45
|
.replace(/\\\[([^[\]\n]*)\\\](?!\s*[([])/g, '[$1]');
|
|
44
46
|
return head + rest;
|
package/dist/markdown.js
CHANGED
|
@@ -9,6 +9,7 @@ import { readFileSync, existsSync } from 'node:fs';
|
|
|
9
9
|
import { resolve, relative, dirname, basename } from 'node:path';
|
|
10
10
|
import { createHash } from 'node:crypto';
|
|
11
11
|
import MarkdownIt from 'markdown-it';
|
|
12
|
+
import { resolveWikilinks } from './obsidian.js';
|
|
12
13
|
export const escapeXml = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
13
14
|
export const hashOf = (s) => createHash('sha256').update(s).digest('hex');
|
|
14
15
|
/** markdown-it 이 퍼센트 인코딩한 링크/이미지 경로를 실제 경로로 디코드(실패 시 원본). */
|
|
@@ -83,11 +84,15 @@ export function splitTitleAndBody(markdown, fallback) {
|
|
|
83
84
|
lines.splice(i, 1);
|
|
84
85
|
return { title, body: lines.join('\n') };
|
|
85
86
|
}
|
|
86
|
-
/**
|
|
87
|
-
|
|
87
|
+
/**
|
|
88
|
+
* 본문 markdown 을 storage format 으로 변환(base 기준 상대경로로 링크·이미지 해석).
|
|
89
|
+
* resolveLink 를 주면 Obsidian 의 [[wikilink]]·![[embed]] 를 먼저 표준 링크로 정규화한다.
|
|
90
|
+
*/
|
|
91
|
+
export function toStorage(markdown, rel, titleIndex, base, resolveLink) {
|
|
88
92
|
baseDir = base;
|
|
89
93
|
ctx = { fileDir: dirname(rel) === '.' ? '' : dirname(rel), titleIndex, images: [], internalLinks: 0, linkStack: [], linkedTitles: new Set() };
|
|
90
|
-
const
|
|
94
|
+
const src = resolveLink ? resolveWikilinks(markdown, resolveLink) : markdown;
|
|
95
|
+
const storage = md.render(src);
|
|
91
96
|
return { storage, images: ctx.images, internalLinks: ctx.internalLinks, linkedTitles: [...ctx.linkedTitles] };
|
|
92
97
|
}
|
|
93
98
|
/**
|
package/dist/obsidian.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Obsidian 브릿지 — vault 관례와 이 도구의 표준 마크다운 사이를 잇는다.
|
|
3
|
+
*
|
|
4
|
+
* 원칙: **표준을 내보내고, 방언을 받아들인다.**
|
|
5
|
+
* - push: vault 에서 쓴 YAML frontmatter · [[wikilink]] · ![[embed]] 를 이해해 그대로 발행한다.
|
|
6
|
+
* - pull: Obsidian·GitHub·VS Code 어디서나 열리는 표준 상대 링크로 내보낸다
|
|
7
|
+
* (frontmatter 의 pageId 가 왕복 동기화의 앵커가 된다).
|
|
8
|
+
*
|
|
9
|
+
* frontmatter 는 평면 `key: value` 만 다룬다. 중첩·리스트가 필요할 만큼 복잡한 메타데이터는
|
|
10
|
+
* 이 도구의 관심사가 아니며, 알 수 없는 키는 건드리지 않고 보존한다.
|
|
11
|
+
*/
|
|
12
|
+
const FENCE = /^\s*(?:```|~~~)/;
|
|
13
|
+
// [[대상]] · [[대상|별칭]] · [[대상#헤딩]] · ![[임베드]]
|
|
14
|
+
const WIKILINK = /(!?)\[\[([^\]|#]+)(#[^\]|]+)?(?:\|([^\]]+))?\]\]/g;
|
|
15
|
+
/**
|
|
16
|
+
* 문서 맨 앞의 YAML frontmatter 를 분리한다. 없으면 data 는 빈 객체.
|
|
17
|
+
* `key: value` 한 줄짜리만 인식하고, 값의 감싼 따옴표는 벗긴다.
|
|
18
|
+
*/
|
|
19
|
+
export function splitFrontmatter(raw) {
|
|
20
|
+
const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/);
|
|
21
|
+
if (!m)
|
|
22
|
+
return { data: {}, body: raw };
|
|
23
|
+
const data = {};
|
|
24
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
25
|
+
const kv = line.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/);
|
|
26
|
+
if (!kv)
|
|
27
|
+
continue; // 중첩/리스트 등 평면이 아닌 줄은 무시(보존은 하지 않음)
|
|
28
|
+
data[kv[1]] = kv[2].trim().replace(/^["'](.*)["']$/, '$1');
|
|
29
|
+
}
|
|
30
|
+
return { data, body: raw.slice(m[0].length) };
|
|
31
|
+
}
|
|
32
|
+
/** 평면 frontmatter 블록 생성. 값이 비면 그 키는 생략한다. */
|
|
33
|
+
export function buildFrontmatter(data) {
|
|
34
|
+
const lines = Object.entries(data)
|
|
35
|
+
.filter(([, v]) => v !== undefined && v !== null && v !== '')
|
|
36
|
+
// 콜론·따옴표·선행 특수문자가 있으면 YAML 이 깨지므로 인용한다
|
|
37
|
+
.map(([k, v]) => `${k}: ${/[:#"'\n]|^\s|\s$/.test(v) ? JSON.stringify(v) : v}`);
|
|
38
|
+
return lines.length ? `---\n${lines.join('\n')}\n---\n\n` : '';
|
|
39
|
+
}
|
|
40
|
+
/** 공백이 있으면 <...> 로 감싼다(markdown-it 이 목적지를 통째로 파싱하도록). */
|
|
41
|
+
const dest = (p) => (/\s/.test(p) ? `<${p}>` : p);
|
|
42
|
+
/**
|
|
43
|
+
* 마크다운 본문에서 "산문" 부분에만 변환을 적용한다.
|
|
44
|
+
* 코드블록(``` / ~~~)과 인라인 코드(`...`) 안은 그대로 둔다 — 링크처럼 생긴 예제 코드를
|
|
45
|
+
* 망가뜨리지 않기 위해서다.
|
|
46
|
+
*/
|
|
47
|
+
function mapProse(md, fn) {
|
|
48
|
+
let inFence = false;
|
|
49
|
+
return md
|
|
50
|
+
.split('\n')
|
|
51
|
+
.map((line) => {
|
|
52
|
+
if (FENCE.test(line)) {
|
|
53
|
+
inFence = !inFence;
|
|
54
|
+
return line;
|
|
55
|
+
}
|
|
56
|
+
if (inFence)
|
|
57
|
+
return line;
|
|
58
|
+
// 홀수 인덱스 = 인라인 코드 스팬 → 원문 유지
|
|
59
|
+
return line
|
|
60
|
+
.split(/(`+[^`]*`+)/)
|
|
61
|
+
.map((seg, i) => (i % 2 ? seg : fn(seg)))
|
|
62
|
+
.join('');
|
|
63
|
+
})
|
|
64
|
+
.join('\n');
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* [[wikilink]] · ![[embed]] → 표준 마크다운 링크.
|
|
68
|
+
* 해석에 실패한 링크는 원문 그대로 남겨 (조용히 사라지지 않고) 눈에 띄게 한다.
|
|
69
|
+
*/
|
|
70
|
+
export function resolveWikilinks(md, resolve) {
|
|
71
|
+
return mapProse(md, (s) => s.replace(WIKILINK, (whole, bang, target, hash, alias) => {
|
|
72
|
+
const embed = bang === '!';
|
|
73
|
+
const found = resolve(target.trim(), embed);
|
|
74
|
+
if (!found)
|
|
75
|
+
return whole; // 해석 실패 → 원문 보존
|
|
76
|
+
const label = (alias ?? target).trim();
|
|
77
|
+
return embed ? `})` : `[${label}](${dest(found + (hash ?? ''))})`;
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
// [text](dest) — dest 는 <...> 로 감싸였을 수 있다. 앞의 ! 는 이미지 임베드.
|
|
81
|
+
const MD_LINK = /(!?)\[([^\]]*)\]\((<[^>]+>|[^()\s]+)\)/g;
|
|
82
|
+
/**
|
|
83
|
+
* 표준 마크다운 링크 → [[wikilink]] (resolveWikilinks 의 역변환).
|
|
84
|
+
* toWikilink 가 null 을 주면(= 외부 URL·이미지·대상 불명) 그 링크는 그대로 둔다.
|
|
85
|
+
* 이미지 임베드(`![...]()`)는 건드리지 않는다 — 표준 문법이 Obsidian 에서도 그대로 렌더된다.
|
|
86
|
+
*/
|
|
87
|
+
export function linksToWikilinks(md, toWikilink) {
|
|
88
|
+
return mapProse(md, (s) => s.replace(MD_LINK, (whole, bang, label, rawDest) => {
|
|
89
|
+
if (bang === '!')
|
|
90
|
+
return whole; // 이미지는 표준 링크 유지
|
|
91
|
+
const d = rawDest.replace(/^<(.*)>$/, '$1');
|
|
92
|
+
return toWikilink(d, label) ?? whole;
|
|
93
|
+
}));
|
|
94
|
+
}
|
package/dist/pull.js
CHANGED
|
@@ -2,16 +2,22 @@
|
|
|
2
2
|
* `confluence-sync pull <pageId|url> [--out <dir>] [--children]`
|
|
3
3
|
* Confluence 페이지를 읽어 Markdown(.md) 으로 생성한다(역방향).
|
|
4
4
|
* 자식이 있는 페이지는 push 관례를 역으로 적용해 <slug>/README.md 폴더로 펼친다.
|
|
5
|
-
* 첨부는
|
|
5
|
+
* 첨부는 attachments/<문서명>/ 하위에 내려받고 이미지 링크를 그 경로로 재작성한다.
|
|
6
|
+
*
|
|
7
|
+
* 함께 받은 페이지끼리의 내부 링크는 마지막에 상대 .md 경로로 이어 붙인다(Obsidian 그래프·백링크가 살아난다).
|
|
8
|
+
* 각 문서 머리에 pageId 를 담은 frontmatter 를 남겨, 매핑 파일 없이도 push 가 원본 페이지를 다시 찾아간다.
|
|
6
9
|
*/
|
|
7
|
-
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
8
|
-
import { resolve, join, dirname, basename } from 'node:path';
|
|
10
|
+
import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
|
11
|
+
import { resolve, join, dirname, basename, relative } from 'node:path';
|
|
9
12
|
import { readEnv, requireEnv } from './config.js';
|
|
10
13
|
import { createClient } from './confluence.js';
|
|
11
14
|
import { htmlToMarkdown, codeLanguagesFromStorage } from './html2md.js';
|
|
15
|
+
import { buildFrontmatter } from './obsidian.js';
|
|
12
16
|
import { cyan, dim, green, red, yellow } from './colors.js';
|
|
13
17
|
// 첨부 이미지는 항상 <md위치>/attachments/<문서명>/ 하위에 저장한다.
|
|
14
18
|
const ASSETS_DIR = 'attachments';
|
|
19
|
+
/** 마크다운 링크 중 Confluence 페이지를 가리키는 것: [text](https://.../pages/<id>/...) */
|
|
20
|
+
const PAGE_LINK = /\[([^\]]*)\]\(<?(https?:\/\/[^\s)<>]*?\/pages\/(\d+)[^\s)<>]*?)>?\)/g;
|
|
15
21
|
function optVal(argv, name) {
|
|
16
22
|
const i = argv.indexOf(name);
|
|
17
23
|
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined;
|
|
@@ -28,8 +34,11 @@ function parseContentId(input) {
|
|
|
28
34
|
function slug(title) {
|
|
29
35
|
return title.replace(/[\/\\:*?"<>|]/g, '-').replace(/\s+/g, ' ').trim() || 'untitled';
|
|
30
36
|
}
|
|
37
|
+
/** 공백이 있으면 <...> 로 감싼 마크다운 링크 목적지 */
|
|
38
|
+
const dest = (p) => (/\s/.test(p) ? `<${p}>` : p);
|
|
31
39
|
/** 페이지 1건을 .md 로 생성(자식 페이지/폴더가 있으면 <slug>/README.md 폴더로). */
|
|
32
|
-
async function pullPage(
|
|
40
|
+
async function pullPage(ctx, node, destDir) {
|
|
41
|
+
const { client, withChildren } = ctx;
|
|
33
42
|
const { id, title, html, storage } = node;
|
|
34
43
|
// 페이지도 하위 페이지 + 하위 폴더를 모두 가질 수 있다(폴더가 페이지 밑에 올 수 있음).
|
|
35
44
|
const childFolders = withChildren ? await client.getChildFolders(id) : [];
|
|
@@ -66,19 +75,33 @@ async function pullPage(client, node, destDir, withChildren) {
|
|
|
66
75
|
}
|
|
67
76
|
}
|
|
68
77
|
}
|
|
69
|
-
|
|
78
|
+
// frontmatter 의 pageId 는 push 왕복의 앵커다(매핑 파일이 없어도 원본 페이지를 찾아간다).
|
|
79
|
+
// 취향 옵션이 아니라 중복 페이지 생성을 막는 장치이므로 항상 쓴다. Obsidian 은 이를 속성으로 보여준다.
|
|
80
|
+
const fm = buildFrontmatter({
|
|
81
|
+
title,
|
|
82
|
+
pageId: id,
|
|
83
|
+
spaceKey: ctx.spaceKey,
|
|
84
|
+
source: node.url ?? '',
|
|
85
|
+
updated: node.updated ?? '',
|
|
86
|
+
});
|
|
87
|
+
// 제목은 frontmatter 의 title 하나로 충분하다. 본문에 `# 제목` 을 또 넣으면
|
|
88
|
+
// 제목을 따로 표시하는 뷰어(Obsidian 등)에서 두 번 보인다. push 도 title 속성을 먼저 읽는다.
|
|
89
|
+
writeFileSync(filePath, fm ? `${fm}${body}` : `# ${title}\n\n${body}`);
|
|
90
|
+
ctx.written.push(filePath);
|
|
91
|
+
ctx.pathById.set(id, filePath);
|
|
70
92
|
const imgNote = referenced.size ? `, 이미지 ${referenced.size}` : '';
|
|
71
93
|
console.log(` ${green('+ 생성')} ${filePath} ${dim(`(#${id}${imgNote})`)}`);
|
|
72
94
|
const counts = { pages: 1, folders: 0 };
|
|
73
95
|
for (const f of childFolders)
|
|
74
|
-
add(counts, await pullNode(
|
|
96
|
+
add(counts, await pullNode(ctx, f.id, folder));
|
|
75
97
|
for (const c of childPages)
|
|
76
|
-
add(counts, await pullNode(
|
|
98
|
+
add(counts, await pullNode(ctx, c.id, folder));
|
|
77
99
|
return counts;
|
|
78
100
|
}
|
|
79
101
|
function add(acc, c) { acc.pages += c.pages; acc.folders += c.folders; }
|
|
80
102
|
/** 노드(페이지/폴더)를 재귀적으로 가져온다. 폴더는 디렉토리(본문 없음)로 만든다. 노드 하나 실패는 스킵. */
|
|
81
|
-
async function pullNode(
|
|
103
|
+
async function pullNode(ctx, id, destDir) {
|
|
104
|
+
const { client, withChildren } = ctx;
|
|
82
105
|
try {
|
|
83
106
|
const node = await client.getNode(id);
|
|
84
107
|
if (node.type === 'folder') {
|
|
@@ -88,9 +111,9 @@ async function pullNode(client, id, destDir, withChildren) {
|
|
|
88
111
|
const counts = { pages: 0, folders: 1 };
|
|
89
112
|
if (withChildren) {
|
|
90
113
|
for (const f of await client.getChildFolders(id))
|
|
91
|
-
add(counts, await pullNode(
|
|
114
|
+
add(counts, await pullNode(ctx, f.id, dir));
|
|
92
115
|
for (const p of await client.getChildPages(id))
|
|
93
|
-
add(counts, await pullNode(
|
|
116
|
+
add(counts, await pullNode(ctx, p.id, dir));
|
|
94
117
|
}
|
|
95
118
|
else {
|
|
96
119
|
console.log(dim(' (하위 내용은 --children 으로 가져옵니다)'));
|
|
@@ -98,13 +121,46 @@ async function pullNode(client, id, destDir, withChildren) {
|
|
|
98
121
|
return counts;
|
|
99
122
|
}
|
|
100
123
|
// page: getNode 결과(html + storage) 그대로 사용
|
|
101
|
-
return await pullPage(
|
|
124
|
+
return await pullPage(ctx, node, destDir);
|
|
102
125
|
}
|
|
103
126
|
catch (e) {
|
|
104
127
|
console.error(red(` ✗ 실패 #${id}`) + `\n${e.message}`);
|
|
105
128
|
return { pages: 0, folders: 0 };
|
|
106
129
|
}
|
|
107
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* 함께 받은 페이지를 가리키는 절대 Confluence URL 을 상대 .md 링크(또는 [[wikilink]])로 바꾼다.
|
|
133
|
+
* 이번에 받지 않은 페이지의 링크는 절대 URL 그대로 둔다(깨진 링크를 만들지 않는다).
|
|
134
|
+
* 반환값은 재작성한 링크 수.
|
|
135
|
+
*/
|
|
136
|
+
export function relinkPass(written, pathById, wikilinks) {
|
|
137
|
+
const noExt = (p) => basename(p).replace(/\.md$/i, '');
|
|
138
|
+
// wikilink 는 vault 어디서든 "이름"으로 찾으므로, 파일명이 유일할 때만 안전하게 쓸 수 있다.
|
|
139
|
+
const nameCount = new Map();
|
|
140
|
+
for (const p of pathById.values())
|
|
141
|
+
nameCount.set(noExt(p), (nameCount.get(noExt(p)) ?? 0) + 1);
|
|
142
|
+
let count = 0;
|
|
143
|
+
for (const file of written) {
|
|
144
|
+
const before = readFileSync(file, 'utf8');
|
|
145
|
+
const after = before.replace(PAGE_LINK, (whole, text, url, id) => {
|
|
146
|
+
const target = pathById.get(id);
|
|
147
|
+
if (!target || target === file)
|
|
148
|
+
return whole; // 못 받은 페이지·자기 자신 → 원본 URL 유지
|
|
149
|
+
count++;
|
|
150
|
+
const name = noExt(target);
|
|
151
|
+
const label = (text || name).trim();
|
|
152
|
+
if (wikilinks && nameCount.get(name) === 1) {
|
|
153
|
+
return label === name ? `[[${name}]]` : `[[${name}|${label}]]`;
|
|
154
|
+
}
|
|
155
|
+
const anchor = url.includes('#') ? '#' + url.split('#').slice(1).join('#') : '';
|
|
156
|
+
const rel = relative(dirname(file), target).split('\\').join('/');
|
|
157
|
+
return `[${label}](${dest(rel + anchor)})`;
|
|
158
|
+
});
|
|
159
|
+
if (after !== before)
|
|
160
|
+
writeFileSync(file, after);
|
|
161
|
+
}
|
|
162
|
+
return count;
|
|
163
|
+
}
|
|
108
164
|
export async function runPull(argv) {
|
|
109
165
|
const withChildren = argv.includes('--children');
|
|
110
166
|
const wholeSpace = argv.includes('--space');
|
|
@@ -112,6 +168,19 @@ export async function runPull(argv) {
|
|
|
112
168
|
const env = readEnv();
|
|
113
169
|
requireEnv(env);
|
|
114
170
|
const client = createClient({ baseUrl: env.baseUrl, email: env.email, token: env.token }, { force: false, verify: false });
|
|
171
|
+
const ctx = {
|
|
172
|
+
client,
|
|
173
|
+
withChildren,
|
|
174
|
+
obsidian: argv.includes('--obsidian'),
|
|
175
|
+
spaceKey: env.spaceKey,
|
|
176
|
+
written: [],
|
|
177
|
+
pathById: new Map(),
|
|
178
|
+
};
|
|
179
|
+
const done = (c) => {
|
|
180
|
+
const relinked = relinkPass(ctx.written, ctx.pathById, ctx.obsidian);
|
|
181
|
+
console.log(`\n${green('완료')} 페이지 ${c.pages}개${c.folders ? `, 폴더 ${c.folders}개` : ''} 생성` +
|
|
182
|
+
(relinked ? ` ${cyan(`내부링크 ${relinked}개 연결`)}` : ''));
|
|
183
|
+
};
|
|
115
184
|
// --space: 스페이스 홈페이지(콘텐츠 트리 루트)부터 전체를 재귀적으로 가져옴
|
|
116
185
|
if (wholeSpace) {
|
|
117
186
|
const { homepageId } = await client.getSpaceInfo(env.spaceKey);
|
|
@@ -120,8 +189,8 @@ export async function runPull(argv) {
|
|
|
120
189
|
process.exit(1);
|
|
121
190
|
}
|
|
122
191
|
console.log(`${dim('pull:')} space ${cyan(env.spaceKey)} ${dim('→')} ${cyan(outDir)} ${dim('(전체)')}`);
|
|
123
|
-
|
|
124
|
-
|
|
192
|
+
ctx.withChildren = true;
|
|
193
|
+
done(await pullNode(ctx, homepageId, outDir));
|
|
125
194
|
return;
|
|
126
195
|
}
|
|
127
196
|
// 위치 인자: ['pull', <pageId|url>, ...]
|
|
@@ -146,6 +215,5 @@ export async function runPull(argv) {
|
|
|
146
215
|
process.exit(1);
|
|
147
216
|
}
|
|
148
217
|
console.log(`${dim('pull:')} #${contentId} ${dim('→')} ${cyan(outDir)}${withChildren ? dim(' (+children)') : ''}`);
|
|
149
|
-
|
|
150
|
-
console.log(`\n${green('완료')} 페이지 ${pages}개${folders ? `, 폴더 ${folders}개` : ''} 생성`);
|
|
218
|
+
done(await pullNode(ctx, contentId, outDir));
|
|
151
219
|
}
|
package/dist/repair.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 이미 내려받아 둔 .md 를 고친다(사후 보정).
|
|
3
|
+
*
|
|
4
|
+
* html2md 가 개선되기 전에 pull 한 문서에는 변환기 결함의 흔적이 남아 있다.
|
|
5
|
+
* 스페이스를 통째로 다시 받지 않고도 같은 결과를 얻도록, 그 흔적들만 골라 되돌린다.
|
|
6
|
+
* 파일을 덮어쓰므로 **확실한 것만** 고치고, 애매하면 손대지 않는다.
|
|
7
|
+
*/
|
|
8
|
+
import { relaxEscapes } from './html2md.js';
|
|
9
|
+
import { splitFrontmatter } from './obsidian.js';
|
|
10
|
+
const emptyStats = () => ({ cssJunk: 0, codeLang: 0, trailingWs: 0, escapes: 0, tightList: 0, dupTitle: 0 });
|
|
11
|
+
export const totalFixes = (s) => Object.values(s).reduce((a, b) => a + b, 0);
|
|
12
|
+
const FENCE = /^\s*(?:```|~~~)/;
|
|
13
|
+
/** 리스트 항목이면 종류('ul'|'ol'), 아니면 null. 종류가 다르면 별개의 리스트다. */
|
|
14
|
+
function listKind(line) {
|
|
15
|
+
if (/^[ \t]*[-*+]\s/.test(line))
|
|
16
|
+
return 'ul';
|
|
17
|
+
if (/^[ \t]*\d+[.)]\s/.test(line))
|
|
18
|
+
return 'ol';
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Confluence export_view 는 색상 텍스트용 인라인 <style> 을 본문에(제목 안까지) 끼워 넣는데,
|
|
23
|
+
* 예전 변환기는 그 CSS 텍스트를 그대로 뱉었다. `[data-colorid=xxx]{color:#fff;}` 같은 잔해다.
|
|
24
|
+
* (turndown 이 이스케이프해 `\[data-colorid=...\]` 형태로 남아 있기도 하다.)
|
|
25
|
+
*/
|
|
26
|
+
function stripCssJunk(line) {
|
|
27
|
+
return line
|
|
28
|
+
.replace(/\\?\[data-colorid=[^\]]*\\?\]\s*\{[^}]*\}/g, '') // 선택자 + 규칙 블록
|
|
29
|
+
.replace(/\\?\[data-colorid=[^\]]*\\?\]/g, '') // 선택자만 남은 것
|
|
30
|
+
.replace(/\{\s*color\s*:\s*[^}]*\}/g, ''); // 규칙만 남은 것
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* 실제 Java 코드로 보이는가?
|
|
34
|
+
* Confluence 는 언어를 지정하지 않은 code 매크로를 export 할 때 `brush: java` 를 기본으로 붙인다.
|
|
35
|
+
* 그래서 예전 변환기는 거의 모든 코드블록을 ```java 로 찍었다. 진짜 Java 만 남기고 plaintext 로 되돌린다.
|
|
36
|
+
*/
|
|
37
|
+
function looksLikeJava(code) {
|
|
38
|
+
return (/^\s*(package|import)\s+(java|javax|com|org)\b/m.test(code) ||
|
|
39
|
+
/\b(public|private|protected)\s+(static\s+)?(final\s+)?(class|interface|enum|void)\b/.test(code) ||
|
|
40
|
+
/\bSystem\.(out|err)\.print/.test(code) ||
|
|
41
|
+
/@Override\b/.test(code));
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* 리스트 항목 사이의 빈 줄을 없앤다(Confluence 의 <li><p> 때문에 생긴 loose list).
|
|
45
|
+
* 앞뒤가 **같은 종류**의 항목일 때만 붙인다 — 번호 목록과 글머리 목록이 잇달아 나오는 경우
|
|
46
|
+
* 그 사이의 빈 줄은 두 리스트를 갈라놓는 구분자라 지우면 하나로 합쳐져 버린다.
|
|
47
|
+
*/
|
|
48
|
+
function tightenLists(lines, stats) {
|
|
49
|
+
const out = [];
|
|
50
|
+
let inFence = false;
|
|
51
|
+
for (let i = 0; i < lines.length; i++) {
|
|
52
|
+
const line = lines[i];
|
|
53
|
+
if (FENCE.test(line))
|
|
54
|
+
inFence = !inFence;
|
|
55
|
+
if (!inFence && line.trim() === '' && out.length) {
|
|
56
|
+
const prev = listKind(out[out.length - 1]);
|
|
57
|
+
const next = listKind(lines[i + 1] ?? '');
|
|
58
|
+
if (prev && prev === next) {
|
|
59
|
+
stats.tightList++;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
out.push(line);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* frontmatter 에 title 이 있는데 본문 첫 헤딩이 같은 제목이면 그 H1 을 지운다.
|
|
69
|
+
* 제목을 따로 표시하는 뷰어(Obsidian 등)에서 제목이 두 번 보이기 때문이다.
|
|
70
|
+
* 제목이 다르면(본문이 실제로 다른 이야기로 시작하면) 손대지 않는다.
|
|
71
|
+
*/
|
|
72
|
+
function dropDuplicateTitle(src, stats) {
|
|
73
|
+
const { data, body } = splitFrontmatter(src);
|
|
74
|
+
if (!data.title)
|
|
75
|
+
return src;
|
|
76
|
+
const lines = body.split('\n');
|
|
77
|
+
const i = lines.findIndex((l) => l.trim() !== '');
|
|
78
|
+
if (i === -1)
|
|
79
|
+
return src;
|
|
80
|
+
const h1 = lines[i].match(/^#\s+(.*)$/);
|
|
81
|
+
if (!h1 || h1[1].trim() !== data.title.trim())
|
|
82
|
+
return src;
|
|
83
|
+
lines.splice(i, 1);
|
|
84
|
+
while (lines[i]?.trim() === '')
|
|
85
|
+
lines.splice(i, 1); // H1 뒤에 붙어 있던 빈 줄까지
|
|
86
|
+
stats.dupTitle++;
|
|
87
|
+
const fmBlock = src.slice(0, src.length - body.length);
|
|
88
|
+
return fmBlock + lines.join('\n');
|
|
89
|
+
}
|
|
90
|
+
/** 문서 하나를 보정한다. 반환된 text 가 원본과 같으면 고칠 것이 없었다는 뜻. */
|
|
91
|
+
export function repairMarkdown(input) {
|
|
92
|
+
const stats = emptyStats();
|
|
93
|
+
const src = dropDuplicateTitle(input, stats);
|
|
94
|
+
const lines = src.split('\n');
|
|
95
|
+
const out = [];
|
|
96
|
+
let inFence = false;
|
|
97
|
+
let fenceStart = -1; // out 기준, 현재 코드블록 여는 줄의 인덱스
|
|
98
|
+
for (const raw of lines) {
|
|
99
|
+
if (FENCE.test(raw)) {
|
|
100
|
+
if (!inFence) {
|
|
101
|
+
inFence = true;
|
|
102
|
+
fenceStart = out.length;
|
|
103
|
+
out.push(raw);
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
// 코드블록 종료 — 모아둔 내용으로 언어를 판정한다
|
|
107
|
+
inFence = false;
|
|
108
|
+
const opener = out[fenceStart];
|
|
109
|
+
if (/^\s*(?:```|~~~)java\s*$/.test(opener)) {
|
|
110
|
+
const code = out.slice(fenceStart + 1).join('\n');
|
|
111
|
+
if (!looksLikeJava(code)) {
|
|
112
|
+
out[fenceStart] = opener.replace(/java\s*$/, 'plaintext');
|
|
113
|
+
stats.codeLang++;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
out.push(raw);
|
|
117
|
+
}
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (inFence) {
|
|
121
|
+
// 코드 안에서는 줄 끝 공백만 정리한다(코드 의미는 불변). 이스케이프·CSS 는 손대지 않는다.
|
|
122
|
+
const trimmed = raw.replace(/[ \t]+$/, '');
|
|
123
|
+
if (trimmed !== raw)
|
|
124
|
+
stats.trailingWs++;
|
|
125
|
+
out.push(trimmed);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
let line = raw;
|
|
129
|
+
const noCss = stripCssJunk(raw);
|
|
130
|
+
if (noCss !== raw) {
|
|
131
|
+
stats.cssJunk++;
|
|
132
|
+
// 잔해를 걷어낸 자리에 공백이 남는다: "# {제거됨} 제목" → "# 제목"
|
|
133
|
+
line = noCss.replace(/[ \t]{2,}/g, ' ').replace(/[ \t]+$/, '');
|
|
134
|
+
}
|
|
135
|
+
out.push(line);
|
|
136
|
+
}
|
|
137
|
+
// 이스케이프 완화는 코드블록을 알아서 건너뛴다(html2md 와 같은 규칙을 재사용)
|
|
138
|
+
let text = relaxEscapes(tightenLists(out, stats).join('\n'));
|
|
139
|
+
// relaxEscapes 가 어디를 바꿨는지 역추적하긴 어려우니, 사라진 백슬래시 개수로 센다
|
|
140
|
+
const backslashes = (s) => (s.match(/\\/g) || []).length;
|
|
141
|
+
stats.escapes = Math.max(0, backslashes(src) - backslashes(text));
|
|
142
|
+
// CSS 잔해를 걷어내면 빈 껍데기 제목(`#` 만 남은 줄)이 생길 수 있다
|
|
143
|
+
text = text.replace(/^#{1,6}[ \t]*$/gm, '');
|
|
144
|
+
return { text, stats };
|
|
145
|
+
}
|
package/dist/sync.js
CHANGED
|
@@ -14,15 +14,16 @@
|
|
|
14
14
|
* 세부 로직은 ./markdown ./docs ./mapping ./confluence ./config ./init ./help 모듈에 있다.
|
|
15
15
|
*/
|
|
16
16
|
import 'dotenv/config';
|
|
17
|
-
import { existsSync,
|
|
17
|
+
import { existsSync, statSync } from 'node:fs';
|
|
18
18
|
import { resolve, relative } from 'node:path';
|
|
19
19
|
import { readEnv, requireEnv } from './config.js';
|
|
20
|
-
import { toStorage,
|
|
21
|
-
import { collectMarkdown, buildTitleIndex, buildFolderIndex, parentKeyOf, containerKeyOf, sortForSync, resolveSelection, withParents, } from './docs.js';
|
|
20
|
+
import { toStorage, docHash } from './markdown.js';
|
|
21
|
+
import { collectMarkdown, collectAssets, buildTitleIndex, buildFolderIndex, buildVault, vaultResolver, readDoc, parentKeyOf, containerKeyOf, sortForSync, resolveSelection, withParents, } from './docs.js';
|
|
22
22
|
import { loadMapping, saveMapping } from './mapping.js';
|
|
23
23
|
import { createClient } from './confluence.js';
|
|
24
24
|
import { runInit } from './init.js';
|
|
25
25
|
import { runPull } from './pull.js';
|
|
26
|
+
import { runConvert } from './convert.js';
|
|
26
27
|
import { buildIgnorer } from './ignore.js';
|
|
27
28
|
import { printHelp, readPkgVersion } from './help.js';
|
|
28
29
|
import { bold, dim, red, green, yellow, magenta, cyan, gray } from './colors.js';
|
|
@@ -51,7 +52,8 @@ const EXCLUDES = optVals('--exclude');
|
|
|
51
52
|
// 위치 인자(선택 경로/서브커맨드): 플래그·옵션값 제외
|
|
52
53
|
const positionals = [];
|
|
53
54
|
for (let i = 0; i < argv.length; i++) {
|
|
54
|
-
if (argv[i] === '--base' || argv[i] === '--mapping' || argv[i] === '--exclude'
|
|
55
|
+
if (argv[i] === '--base' || argv[i] === '--mapping' || argv[i] === '--exclude'
|
|
56
|
+
|| argv[i] === '--to' || argv[i] === '--out') {
|
|
55
57
|
i++;
|
|
56
58
|
continue;
|
|
57
59
|
}
|
|
@@ -76,6 +78,10 @@ if (positionals[0] === 'pull') {
|
|
|
76
78
|
await runPull(argv);
|
|
77
79
|
process.exit(0);
|
|
78
80
|
}
|
|
81
|
+
if (positionals[0] === 'convert') {
|
|
82
|
+
await runConvert(argv);
|
|
83
|
+
process.exit(0);
|
|
84
|
+
}
|
|
79
85
|
const baseInput = optVal('--base') ?? process.env.CONFLUENCE_SYNC_BASE;
|
|
80
86
|
if (!baseInput) {
|
|
81
87
|
console.error(red('✗ 동기화 루트가 지정되지 않았습니다.') + '\n' +
|
|
@@ -103,6 +109,9 @@ async function main() {
|
|
|
103
109
|
const ignoredCount = collected.length - allRel.length;
|
|
104
110
|
const folderIndex = buildFolderIndex(allRel);
|
|
105
111
|
const titleIndex = buildTitleIndex(allRel, BASE_DIR); // 링크 변환은 항상 전체 기준
|
|
112
|
+
// Obsidian [[wikilink]] 해석용 이름 인덱스. 제외된 문서는 링크 대상에서도 빠진다.
|
|
113
|
+
const vault = buildVault(allRel, collectAssets(BASE_DIR).map((f) => relative(BASE_DIR, f)));
|
|
114
|
+
const render = (rel, body) => toStorage(body, rel, titleIndex, BASE_DIR, vaultResolver(rel, vault));
|
|
106
115
|
const selected = positionals.length
|
|
107
116
|
? withParents(resolveSelection(allRel, positionals, BASE_DIR), folderIndex)
|
|
108
117
|
: allRel;
|
|
@@ -148,11 +157,14 @@ async function main() {
|
|
|
148
157
|
const fex = mapping[`${d}/`];
|
|
149
158
|
console.log(` [${fex?.pageId ? gray('폴더') : green('폴더+')}] 📁 ${d}/`);
|
|
150
159
|
}
|
|
151
|
-
const { title, body } =
|
|
152
|
-
const r =
|
|
160
|
+
const { title, body, fm } = readDoc(BASE_DIR, rel);
|
|
161
|
+
const r = render(rel, body);
|
|
153
162
|
const hash = docHash(title, r);
|
|
154
163
|
const ex = mapping[rel];
|
|
155
|
-
|
|
164
|
+
// 매핑엔 없지만 frontmatter 에 pageId 가 있으면 신규 생성이 아니라 기존 페이지에 연결된다
|
|
165
|
+
const status = !ex?.pageId
|
|
166
|
+
? (fm.pageId ? cyan('연결') : green('신규'))
|
|
167
|
+
: ex.hash === hash ? gray('동일') : yellow('변경');
|
|
156
168
|
const pk = parentKeyOf(rel, folderIndex);
|
|
157
169
|
console.log(` [${status}] ${rel} ${dim('→')} ${cyan(`"${title}"`)} ${dim(`(부모: ${pk ?? 'ROOT'}, 내부링크: ${r.internalLinks}, 이미지: ${r.images.length})`)}`);
|
|
158
170
|
}
|
|
@@ -166,7 +178,7 @@ async function main() {
|
|
|
166
178
|
saveMapping(MAPPING_PATH, {});
|
|
167
179
|
}
|
|
168
180
|
const work = REBUILD ? {} : mapping;
|
|
169
|
-
let created = 0, updated = 0, skipped = 0, recreated = 0, relinked = 0, foldersMade = 0;
|
|
181
|
+
let created = 0, updated = 0, skipped = 0, recreated = 0, relinked = 0, foldersMade = 0, linked = 0;
|
|
170
182
|
const rendered = new Map();
|
|
171
183
|
const recreatedTitles = new Set(); // 이번에 재생성된 문서의 제목
|
|
172
184
|
const touched = new Set(); // 이번에 발행(생성/갱신/재생성)된 문서
|
|
@@ -216,8 +228,8 @@ async function main() {
|
|
|
216
228
|
console.error(yellow(` ✗ 건너뜀 ${rel} (상위 폴더 미생성)`));
|
|
217
229
|
continue;
|
|
218
230
|
}
|
|
219
|
-
const { title, body } =
|
|
220
|
-
const r =
|
|
231
|
+
const { title, body, fm } = readDoc(BASE_DIR, rel);
|
|
232
|
+
const r = render(rel, body);
|
|
221
233
|
const hash = docHash(title, r);
|
|
222
234
|
const pk = parentKeyOf(rel, folderIndex);
|
|
223
235
|
const parentId = pk ? work[pk]?.pageId : env.parentId;
|
|
@@ -226,6 +238,24 @@ async function main() {
|
|
|
226
238
|
console.error(yellow(` ✗ 건너뜀 ${rel} (부모 '${pk}' 가 아직 없음)`));
|
|
227
239
|
continue;
|
|
228
240
|
}
|
|
241
|
+
// pull 로 받은 문서(frontmatter 에 pageId)는 매핑이 없어도 원본 페이지에 다시 연결한다.
|
|
242
|
+
// 매핑 파일을 잃었거나 vault 를 다른 곳에 복제한 경우 중복 생성을 막아준다.
|
|
243
|
+
if (!work[rel]?.pageId && fm.pageId) {
|
|
244
|
+
try {
|
|
245
|
+
if (await client.getContentOrNull(fm.pageId)) {
|
|
246
|
+
work[rel] = { pageId: fm.pageId, title };
|
|
247
|
+
saveMapping(MAPPING_PATH, work);
|
|
248
|
+
console.log(` ${cyan('🔗 연결')} ${rel} ${dim(`→ 기존 페이지 #${fm.pageId}`)}`);
|
|
249
|
+
linked++;
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
console.error(yellow(` ⚠ frontmatter 의 pageId #${fm.pageId} 를 찾을 수 없습니다 → 새로 생성 (${rel})`));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
catch (e) {
|
|
256
|
+
console.error(yellow(` ⚠ pageId 확인 실패 #${fm.pageId}: ${e.message}`));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
229
259
|
try {
|
|
230
260
|
const result = await client.upsertPage(work, spaceId, rel, title, r.storage, hash, parentId);
|
|
231
261
|
saveMapping(MAPPING_PATH, work);
|
|
@@ -282,6 +312,7 @@ async function main() {
|
|
|
282
312
|
` ${magenta(`재생성 ${recreated}`)}` +
|
|
283
313
|
` ${cyan(`링크재연결 ${relinked}`)}` +
|
|
284
314
|
` ${gray(`변경없음 ${skipped}`)}` +
|
|
315
|
+
(linked ? ` ${cyan(`연결 ${linked}`)}` : '') +
|
|
285
316
|
(foldersMade ? ` ${cyan(`폴더 ${foldersMade}`)}` : '') +
|
|
286
317
|
` ${dim(`대상 ${files.length}`)}`);
|
|
287
318
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wonseok-han/confluence-sync",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Confluence Cloud ↔ Markdown 양방향 동기화 CLI — 마크다운을 Confluence 페이지로 발행(push)하고, Confluence 페이지·폴더·스페이스를 마크다운으로 가져옵니다(pull).",
|
|
6
6
|
"keywords": [
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
"node": ">=20"
|
|
27
27
|
},
|
|
28
28
|
"bin": {
|
|
29
|
-
"confluence-sync": "dist/sync.js"
|
|
29
|
+
"confluence-sync": "dist/sync.js",
|
|
30
|
+
"csync": "dist/sync.js"
|
|
30
31
|
},
|
|
31
32
|
"files": [
|
|
32
33
|
"dist",
|